sync 包与原子操作
需要预先了解:
1. sync 包与原子操作
多个 goroutine 同时读写同一份数据,如果不加保护,就会产生竞态条件(race condition)。这一篇讲清楚竞态是怎么发生的、怎么用 -race 检测它,以及 sync.Mutex、sync.RWMutex、sync/atomic、sync.Map、sync.Pool 各自的适用场景。
1.1 竞态条件
var counter int
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter =", counter)
}
期望值是 1000,实际运行可能得到:
counter = 968
原因在于 counter++ 不是一条指令,而是三步:
读取 counter 的值 → 加 1 → 写回 counter
两个 goroutine 交错执行时就会丢更新:
时间 goroutine A goroutine B counter
| 读取 (0) 0
| 读取 (0) 0
| 加 1,准备写 1 0
| 加 1,准备写 1 0
| 写回 1 1
| 写回 1 1 ← 两次自增只加了一次
-race 检测器
Go 内置数据竞争检测器,加上 -race 就能让它在运行时报告:
go run -race .
输出:
==================
WARNING: DATA RACE
Read at 0x000140263a50 by goroutine 8:
main.main.func1()
.../race/main.go:16 +0x74
Previous write at 0x000140263a50 by goroutine 10:
main.main.func1()
.../race/main.go:16 +0x8c
==================
报告里会给出读写发生的地址、goroutine 编号和代码位置。-race 有性能开销(内存和速度都有影响),适合在开发和 CI 中开启:
go test -race ./...
注意:竞态检测器只能发现实际发生的竞争,不能证明"没有竞争"。测试用例没覆盖到的路径上的竞态不会被报出来。
1.2 sync.Mutex
最直接的解决办法是加锁,把"读—改—写"包成一个临界区(critical section):
type SafeCounter struct {
mu sync.Mutex
n int
}
func (c *SafeCounter) Incr() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
go run -race .
counter = 1000
几点注意事项:
Mutex的零值可用,不需要sync.NewMutex();- 用
defer解锁,避免函数中途return或 panic 时忘记解锁; - 临界区尽量小,只保护共享数据的读写,不要在里面做 IO 或耗时计算;
- 不要把
Mutex拷贝出去。sync.Mutex拷贝后会变成两把独立的锁,保护不了同一份数据。含Mutex的结构体应该用指针传递,或者作为结构体字段时只用指针接收者方法。
忘记解锁会让其他 goroutine 永久阻塞,程序看起来"卡住",最后可能报:
fatal error: all goroutines are asleep - deadlock!
1.3 sync.RWMutex
读多写少的场景用读写锁:多个读者可以同时进入,写者独占。
var rw sync.RWMutex
rw.RLock() // 读锁,可以多个同时持有
defer rw.RUnlock()
rw.Lock() // 写锁,独占
defer rw.Unlock()
for i := 0; i < 5; i++ {
go func(id int) {
rw.RLock()
defer rw.RUnlock()
fmt.Println("读者", id, "读到:", data["x"])
}(i)
}
go func() {
rw.Lock()
defer rw.Unlock()
data["x"] = 42
}()
读锁之间不互斥,写锁与任何锁互斥。RWMutex 比 Mutex 复杂,读写比例不悬殊时反而更慢;读远多于写(比如读占比 90% 以上)时才值得用。
注:Go 的
RWMutex对写者有优先照顾(写者到来后新的读者会排队),但仍然不能保证绝对的公平。持续不断的读请求可能让写者等很久,这就是"写饥饿"。需要严格公平性时要换用其他方案。
1.4 死锁
死锁是多个 goroutine 互相等待对方持有的资源,永远无法继续。最简单的形式是自锁:
var mu sync.Mutex
mu.Lock()
fmt.Println("第一次加锁成功")
mu.Lock() // 自己等自己
第一次加锁成功
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(...)
Mutex 不可重入——同一个 goroutine 重复 Lock 会死锁。互等的形式是 A 持有锁 1 等锁 2、B 持有锁 2 等锁 1,解决办法是约定所有代码按同一顺序获取锁。
运行时只能在"所有 goroutine 都阻塞"时检测出死锁;如果还有别的 goroutine 在干活,死锁可能表现为请求超时而不是 fatal error。
1.5 sync/atomic
对于简单的计数、标志位,用**原子操作(atomic operation)**可以避免锁的开销:
var n int64
atomic.AddInt64(&n, 1) // 原子加
atomic.LoadInt64(&n) // 原子读
atomic.StoreInt64(&n, 0) // 原子写
Go 1.19 起提供了类型化的原子类型,不用再传指针:
var c atomic.Int64
c.Add(1)
c.Load()
c.Store(0)
c.Swap(100)
c.CompareAndSwap(100, 200)
泛型的原子指针 atomic.Pointer[T] 也在这个版本引入:
type Config struct {
Debug bool
}
var p atomic.Pointer[Config]
p.Store(&Config{Debug: true})
fmt.Println(p.Load().Debug) // true
CAS 循环
**CAS(Compare-And-Swap)**是"如果当前值等于期望值就替换成新值",返回是否成功。它可以用来自旋地实现无锁更新:
var m int64
for {
old := atomic.LoadInt64(&m)
if atomic.CompareAndSwapInt64(&m, old, old+10) {
break
}
// 失败说明有人改过,重读再试
}
读—比较—写之间可能有其他 goroutine 修改,所以必须循环重试。CAS 适合"更新逻辑简单"的场景;逻辑复杂时 CAS 循环会很难写对,直接用锁更稳妥。
使用约束
原子操作只能保证单个变量的原子性。下面这种组合并不安全:
atomic.AddInt64(&a, 1)
atomic.AddInt64(&b, 1)
两行之间其他 goroutine 可能看到 a 已经加过而 b 没加的状态。需要多个变量一起保持一致时,用 Mutex 保护整个临界区。
另外,原子操作和普通读写混用也会产生竞态——要么全用原子操作,要么全用锁。
1.6 sync.Map
sync.Map 是并发安全的 map,用法与普通 map 类似:
var m sync.Map
m.Store("a", 1)
v, ok := m.Load("a")
v, ok = m.LoadOrStore("c", 3) // 不存在则存入并返回存入的值
m.Delete("b")
m.Range(func(k, v any) bool {
fmt.Println(k, v)
return true // 返回 false 则停止遍历
})
它的适用场景比较窄:
- key 集合基本稳定(写入一次、之后只读);
- 多个 goroutine 读多写少,且各自访问的 key 比较分散。
其他场景下"普通 map + RWMutex"往往更简单、更快。一个读密集的并发基准测试:
BenchmarkSyncMapRead-24 255427569 0.8787 ns/op
BenchmarkMutexMapRead-24 5672698 37.44 ns/op
(100 个 key,读同一个 key,24 个并行 goroutine;数字随机器不同。)
sync.Map 的类型是 any,取出来要断言,也失去了编译期类型检查。能用泛型 + 锁封装的场景,优先自己封装。
1.7 sync.Pool
sync.Pool 用来复用临时对象,减少分配和 GC 压力:
pool := sync.Pool{
New: func() any {
buf := make([]byte, 0, 1024)
return &buf
},
}
b1 := pool.Get().(*[]byte)
*b1 = append(*b1, 'a')
pool.Put(b1)
b2 := pool.Get().(*[]byte)
fmt.Printf("从池中取出: len=%d cap=%d\n", len(*b2), cap(*b2))
从池中取出: len=1 cap=1024
注意两点:
Get不保证返回什么——池里的对象可能被 GC 清空,此时会调用New新建;- GC 会清空池,所以不能把
Pool当作缓存用,它只适合"临时对象的回收站"; - 放回池前要重置对象状态(比如把切片截断成
[:0]),否则会读到上一次的数据。
标准库的 fmt、encoding/json 内部都用 Pool 复用缓冲区。
1.8 选型总结
| 场景 | 推荐 |
|---|---|
| 保护一段共享数据的读写 | sync.Mutex |
| 读远多于写 | sync.RWMutex |
| 单个计数器、标志位的更新 | sync/atomic |
| key 稳定、读多写少的并发 map | sync.Map |
| 复用临时对象 | sync.Pool |
| 在 goroutine 间传递数据/所有权 | channel |
| 取消、超时、传递请求元数据 | context(见 context) |
注:能不用锁就不用锁——先想清楚数据是不是可以只在单个 goroutine 里访问,或者用 channel 传递所有权。锁是最后的手段,不是第一选择。
1.9 练习
- 用
atomic.Int64重写竞态示例里的计数器,再用-race验证没有告警。 - 写一个带读写锁的缓存,提供
Get、Set、Delete三个方法,用并发测试验证正确性。 - 用
sync.Pool复用bytes.Buffer,写一个拼接大量字符串的函数,对比使用池与不使用池的分配次数(testing.B的b.ReportAllocs())。



